<template>
  <div v-show="dragging">
    <div
      class="grid-view__field-dragging"
      :style="{ width: draggingWidth + 'px', left: draggingLeft + 'px' }"
    ></div>
    <div
      class="grid-view__field-target"
      :style="{ left: targetLeft + 'px' }"
    ></div>
  </div>
</template>

<script>
import { notifyIf } from '@baserow/modules/core/utils/error'
import gridViewHelpers from '@baserow/modules/database/mixins/gridViewHelpers'

export default {
  name: 'GridViewFieldDragging',
  mixins: [gridViewHelpers],
  props: {
    view: {
      type: Object,
      required: true,
    },
    fields: {
      type: Array,
      required: true,
    },
    containerWidth: {
      type: Number,
      required: true,
    },
    readOnly: {
      type: Boolean,
      required: true,
    },
  },
  data() {
    return {
      // Indicates if the user is dragging a field to another position.
      dragging: false,
      // The field object that is being dragged.
      field: null,
      // The id of the field where the dragged field must be placed after.
      targetFieldId: null,
      // The horizontal starting position of the mouse.
      mouseStart: 0,
      // The horizontal scrollbar offset starting position.
      scrollStart: 0,
      // The width of the dragging animation, this is equal to the width of the field.
      draggingWidth: 0,
      // The position of the dragging animation.
      draggingLeft: 0,
      // The position of the target indicator where the field is going to be moved to.
      targetLeft: 0,
      // The mouse move event.
      lastMoveEvent: null,
      // Indicates if the user is auto scrolling at the moment.
      autoScrolling: false,
    }
  },
  beforeDestroy() {
    this.cancel()
  },
  methods: {
    getFieldLeft(id) {
      let left = 0
      for (let i = 0; i < this.fields.length; i++) {
        if (this.fields[i].id === id) {
          break
        }
        left += this.getFieldWidth(this.fields[i].id)
      }
      return left
    },
    /**
     * Called when the field dragging must start. It will register the global mouse
     * move, mouse up events and keyup events so that the user can drag the field to
     * the correct position.
     */
    start(field, event) {
      this.field = field
      this.targetFieldId = field.id
      this.dragging = true
      this.mouseStart = event.clientX
      this.scrollStart = this.$parent.$el.scrollLeft
      this.draggingLeft = 0
      this.targetLeft = 0

      this.$el.moveEvent = (event) => this.move(event)
      window.addEventListener('mousemove', this.$el.moveEvent)

      this.$el.upEvent = (event) => this.up(event)
      window.addEventListener('mouseup', this.$el.upEvent)

      this.$el.keydownEvent = (event) => {
        if (event.keyCode === 27) {
          // When the user presses the escape key we want to cancel the action
          this.cancel(event)
        }
      }
      document.body.addEventListener('keydown', this.$el.keydownEvent)
      this.move(event, false)
    },
    /**
     * The move method is called when every time the user moves the mouse while
     * dragging a field. It can also be called while auto scrolling.
     */
    move(event = null, startAutoScroll = true) {
      if (event !== null) {
        event.preventDefault()
        this.lastMoveEvent = event
      } else {
        event = this.lastMoveEvent
      }

      // This is the horizontally scrollable element.
      const element = this.$parent.$el

      this.draggingWidth = this.getFieldWidth(this.field.id)

      // Calculate the left position of the dragging animation. This is the transparent
      // overlay that has the same width as the field.
      this.draggingLeft = Math.min(
        this.getFieldLeft(this.field.id) +
          event.clientX -
          this.mouseStart +
          this.$parent.$el.scrollLeft -
          this.scrollStart,
        this.containerWidth - this.draggingWidth
      )

      // Calculate which after which field we want to place the field that is currently
      // being dragged. This is named the target. We also calculate what position the
      // field would have for visualisation purposes.
      const mouseLeft =
        event.clientX -
        element.getBoundingClientRect().left +
        element.scrollLeft
      let left = 0
      for (let i = 0; i < this.fields.length; i++) {
        const width = this.getFieldWidth(this.fields[i].id)
        const nextWidth =
          i + 1 < this.fields.length
            ? this.getFieldWidth(this.fields[i + 1].id)
            : width
        const leftHalf = left + Math.floor(width / 2)
        const rightHalf = left + width + Math.floor(nextWidth / 2)
        if (i === 0 && mouseLeft < leftHalf) {
          this.targetFieldId = 0
          // The value 1 makes sure it is visible instead of falling outside of the
          // view port.
          this.targetLeft = 1
          break
        }
        if (mouseLeft > leftHalf && mouseLeft < rightHalf) {
          this.targetFieldId = this.fields[i].id
          this.targetLeft = left + width
          break
        }
        left += width
      }

      // If the user is not already auto scrolling, which happens while dragging and
      // moving the element outside of the view port at the left or right side, we
      // might need to initiate that process.
      if (!this.autoScrolling || !startAutoScroll) {
        const relativeLeft = this.draggingLeft - element.scrollLeft
        const relativeRight = relativeLeft + this.getFieldWidth(this.field.id)
        const maxScrollLeft = element.scrollWidth - element.clientWidth
        let speed = 0

        if (relativeLeft < 0 && element.scrollLeft > 0) {
          // If the dragging animation falls out of the left side of the viewport we
          // need to auto scroll to the left.
          speed = -Math.ceil(Math.min(Math.abs(relativeLeft), 100) / 20)
        } else if (
          relativeRight > element.clientWidth &&
          element.scrollLeft < maxScrollLeft
        ) {
          // If the dragging animation falls out of the right side of the viewport we
          // need to auto scroll to the right.
          speed = Math.ceil(
            Math.min(relativeRight - element.clientWidth, 100) / 20
          )
        }

        // If the speed is either a position or negative, so not 0, we know that we
        // need to start auto scrolling.
        if (speed !== 0) {
          this.autoScrolling = true
          this.$emit('scroll', { pixelY: 0, pixelX: speed })
          this.$el.scrollTimeout = setTimeout(() => {
            this.move(null, false)
          }, 1)
        } else {
          this.autoScrolling = false
        }
      }
    },
    /**
     * Can be called when the current dragging state needs to be stopped. It will
     * remove all the created event listeners and timeouts.
     */
    cancel() {
      this.dragging = false
      window.removeEventListener('mousemove', this.$el.moveEvent)
      window.removeEventListener('mouseup', this.$el.upEvent)
      document.body.addEventListener('keydown', this.$el.keydownEvent)
      clearTimeout(this.$el.scrollTimeout)
    },
    /**
     * Called when the user releases the mouse on a the desired position. It will
     * calculate the new position of the field in the list and if it has changed
     * position, then the order in the field options is updated accordingly.
     */
    async up(event) {
      event.preventDefault()
      this.cancel()

      // We don't need to do anything if the field needs to be placed after itself
      // because that wouldn't change the position.
      if (this.field.id === this.targetFieldId) {
        return
      }

      const oldOrder = this.fields.map((field) => field.id)
      // Create an array of field ids in the correct order excluding the field that
      // needs to be repositioned because that one will be added later.
      const newOrder = this.fields
        .filter((field) => field.id !== this.field.id)
        .map((field) => field.id)
      if (this.targetFieldId === 0) {
        // If the target field id is 0 the field needs to be moved to the beginning.
        newOrder.unshift(this.field.id)
      } else {
        // Calculate after which field the field that needs to be repositioned needs to
        // be placed.
        const targetIndex = newOrder.findIndex(
          (id) => id === this.targetFieldId
        )
        newOrder.splice(targetIndex + 1, 0, this.field.id)
      }

      // Check if the new order differs from the old order. If that is not the case we
      // don't need to update the field options because nothing will be changed.
      if (JSON.stringify(oldOrder) === JSON.stringify(newOrder)) {
        return
      }

      try {
        await this.$store.dispatch(
          `${this.storePrefix}view/grid/updateFieldOptionsOrder`,
          {
            order: newOrder,
            readOnly: this.readOnly,
          }
        )
      } catch (error) {
        notifyIf(error, 'view')
      }
    },
  },
}
</script>
